﻿/*
 *  Copyright (c) 2009 Alessandro Pilotti
 *
 *  Permission is hereby granted, free of charge, to any person
 *  obtaining a copy of this software and associated documentation
 *  files (the "Software"), to deal in the Software without
 *  restriction, including without limitation the rights to use,
 *  copy, modify, merge, publish, distribute, sublicense, and/or sell
 *  copies of the Software, and to permit persons to whom the
 *  Software is furnished to do so, subject to the following
 *  conditions:
 *  
 *  The above copyright notice and this permission notice shall be
 *  included in all copies or substantial portions of the Software.
 *  
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 *  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 *  OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 *  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 *  HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 *  WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 *  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 *  OTHER DEALINGS IN THE SOFTWARE.
 */

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;

namespace AlexPilotti.KeyStorageManager
{
    class MacOSXKeychainProviderManager : IKeyStorageProviderManager
    {
        #region IKeyStorageProviderManager Members

        public bool IsAvailable
        {
            get
            { 
                // In case the platform is Unix, check if we are running on Mac OS X with an empiric test
                return (Environment.OSVersion.Platform == PlatformID.MacOSX ||
                        (Environment.OSVersion.Platform == PlatformID.Unix && 
                         File.Exists("/System/Library/Frameworks/Security.framework/Security"))); 
            }
        }

        public IKeyStorageProvider GetProviderInstance()
        {
            return new MacOSXKeychainProvider();
        }

        #endregion
    }

    public sealed class MacOSXKeychainProvider : IKeyStorageProvider
    {
        #region Constructors

        internal MacOSXKeychainProvider()
        {
        }

        #endregion

        #region PInvoke Private Methods

        /// <summary>
        /// Mac OS X framework libs use a different path than standard dll libs. 
        /// In order to avoid using an hard coded full path, a <c>dllmap</c> element in the </c>.config</c> file can be used (Mono extension).
        /// </summary>
        const string coreFoundationFrameworkLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
        const string securityFrameworkLib       = "/System/Library/Frameworks/Security.framework/Security";

        const int noErr = 0;
        const int errSecDuplicateItem = -25299;
        const int errSecItemNotFound = -25300;
        const int errSecInteractionNotAllowed = -25308;

        [DllImport(coreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        private static extern void CFRelease(IntPtr cf);

        [DllImport(securityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        private static extern int SecKeychainAddGenericPassword(IntPtr keychain,
                                                            uint serviceNameLength,
                                                            string serviceName,
                                                            uint accountNameLength,
                                                            string accountName,
                                                            uint passwordLength,
                                                            byte[] passwordData,
                                                            out IntPtr itemRef);

        [DllImport(securityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        private static extern int SecKeychainFindGenericPassword(IntPtr keychainOrArray,
                                                            uint serviceNameLength,
                                                            string serviceName,
                                                            uint accountNameLength,
                                                            string accountName,
                                                            out uint passwordLength,
                                                            out IntPtr passwordData,
                                                            out IntPtr itemRef);


        [DllImport(securityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        private extern static int SecKeychainItemFreeContent(IntPtr attrList, IntPtr data);

        [DllImport(securityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        private static extern int SecKeychainItemModifyAttributesAndData(IntPtr itemRef,
                                                                         IntPtr attrList, uint length, byte[] data);

        [DllImport(securityFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
        private static extern int SecKeychainItemDelete(IntPtr itemRef);

        #endregion

        #region IKeyStorageProvider Members

        public void StoreKey(string name, string secret)
        {
            StoreKey(name, Encoding.UTF8.GetBytes(secret));
        }

        private void CheckStatus(int status, string defaultErrorMessage, IList<int> validValues)
        {
            if (!validValues.Contains(status))
                if (status == errSecInteractionNotAllowed)
                    throw new UIInteractionNotAllowedException();
                else
                    throw new KeyStorageException(defaultErrorMessage);
        }

        private void CheckStatus(int status, string defaultErrorMessage)
        {
            CheckStatus(status, defaultErrorMessage, new int[] { noErr });
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="name"></param>
        /// <param name="secret"></param>
        /// <remarks>
        /// TODO: Add support for SecKeychainGetStatus and SecKeychainUnlock to avoid raining UIInteractionNotAllowedException.
        /// </remarks>
        public void StoreKey(string name, byte[] secret)
        {
            IntPtr itemRef = IntPtr.Zero;
            IntPtr dataPtr = IntPtr.Zero;
            uint dataLen = 0;
            string userName = Environment.UserName;

            try
            {
                FindData(name, userName, out dataPtr, out dataLen, out itemRef);

                if (itemRef != IntPtr.Zero)
                {
                    // Update item
                    int status = SecKeychainItemModifyAttributesAndData(itemRef, IntPtr.Zero, (uint)secret.Length, secret);
                    CheckStatus(status, "Could not update key data");
                }
                else
                {
                    // update item
                    // status: 0 = ok, -25299 = the item already exists
                    int status = SecKeychainAddGenericPassword(IntPtr.Zero, (uint)name.Length, name,
                                                               (uint)userName.Length, userName,
                                                               (uint)secret.Length, secret, out itemRef);
                    CheckStatus(status, "Could not add key");
                }
            }
            finally
            {
                // Don't forget to free up memory
                if (dataPtr != IntPtr.Zero)
                    SecKeychainItemFreeContent(IntPtr.Zero, dataPtr);

                if (itemRef != IntPtr.Zero)
                    CFRelease(itemRef);
            }            
        }

        /// <summary>
        /// The caller must free <see cref="dataPtr"/> using <see cref="SecKeychainItemFreeContent"/> and <see cref="itemRef"/> using <see cref="CFRelease"/>.
        /// </summary>
        /// <param name="name"></param>
        /// <param name="userName"></param>
        /// <param name="dataPtr"></param>
        /// <param name="dataLen"></param>
        /// <param name="itemRef"><c>null</c> if the requested item could not be found.</param>
        private void FindData(string name, string userName, out IntPtr dataPtr, out uint dataLen, out IntPtr itemRef)
        {
            int status = SecKeychainFindGenericPassword(IntPtr.Zero, (uint)name.Length, name,
                                                        (uint)userName.Length, userName,
                                                        out dataLen, out dataPtr, out itemRef);

            CheckStatus(status, "An error occurred retrieving key data", new int[] { noErr, errSecItemNotFound });
        }

        public byte[] RetrieveKey(string name)
        {
            byte[] secret = null;

            IntPtr itemRef = IntPtr.Zero;
            IntPtr dataPtr = IntPtr.Zero;
            uint dataLen;
            try
            {
                FindData(name, Environment.UserName, out dataPtr, out dataLen, out itemRef);

                if (itemRef != IntPtr.Zero)
                {
                    secret = new byte[dataLen];
                    Marshal.Copy(dataPtr, secret, 0, secret.Length);
                }
            }
            finally
            {
                // Don't forget to free up memory
                if(dataPtr != IntPtr.Zero)
                    SecKeychainItemFreeContent(IntPtr.Zero, dataPtr);

                if (itemRef != IntPtr.Zero)
                    CFRelease(itemRef);
            }

            return secret;
        }

        public string RetrieveKeyAsString(string name)
        {
            byte[] data = RetrieveKey(name);
            return data != null ? Encoding.UTF8.GetString(data) : null;            
        }

        public bool DeleteKey(string name)
        {
            bool itemDeleted = false;

            IntPtr itemRef = IntPtr.Zero;
            IntPtr dataPtr = IntPtr.Zero;
            uint dataLen;
            try
            {
                FindData(name, Environment.UserName, out dataPtr, out dataLen, out itemRef);

                if (itemRef != IntPtr.Zero)
                {
                    int status = SecKeychainItemDelete(itemRef);
                    CheckStatus(status, "Could not delete key");

                    itemDeleted = true;
                }
            }
            finally
            {
                // Don't forget to free up memory
                if (dataPtr != IntPtr.Zero)
                    SecKeychainItemFreeContent(IntPtr.Zero, dataPtr);

                if (itemRef != IntPtr.Zero)
                    CFRelease(itemRef);
            }

            return itemDeleted;
        }

        #endregion
    }
}
